Skip to content

Canvas gestures own the pointer until release - #82

Merged
rebaserHEAD merged 1 commit into
mainfrom
fix/pan-pointer-capture
Jul 28, 2026
Merged

Canvas gestures own the pointer until release#82
rebaserHEAD merged 1 commit into
mainfrom
fix/pan-pointer-capture

Conversation

@rebaserHEAD

Copy link
Copy Markdown
Owner

About

Field report: still getting view desyncs when panning and dragging on v1.4.0, despite the #74 self-heal work. So this takes the whole input stack from the top instead of patching again.

The verdict: the architecture was the bug. The canvas listened to element-level mouse events, which structurally cannot see a release that happens off-canvas. Every prior fix (the window mouseup listener, the mouseleave handler, the e.buttons liveness check) shrank the desync window without being able to close it, and two of the patches were causing desyncs of their own:

  • onMouseLeave committed drags at the canvas edge. Graze the edge mid-marquee or mid-entity-move and the drag ended where you never released; pans died crossing onto a side panel.
  • The mouseup handler did not check whether a drag was actually live, so a release without its matching press fired a phantom click into the tool. That is the "selection lands on a spot I never clicked" class.
  • Mouse chords double-started: pressing middle during a left-drag began a pan while the drag stayed active, moving the camera out from under the marquee anchor.

The rework moves the canvas to pointer events with setPointerCapture: a gesture that starts on the canvas owns the pointer until release or cancel, and the browser delivers every move and the release to the canvas no matter where the cursor goes, including outside the window. Eaten releases stop existing as a category instead of being healed after the fact.

Three invariants, each pinned by tests:

  1. Liveness (kept as backstop): e.buttons is ground truth; a stale gesture closes out before anything acts on it.
  2. Pairing: tools only ever see onMouseUp for a drag the canvas started. Phantom releases never reach them.
  3. One gesture at a time: downs are ignored while a gesture is live.

Tools keep their existing onMouseDown/Move/Up interface untouched; only the canvas entry layer changed. pointercancel / lostpointercapture now close gestures properly when the OS takes the pointer. jsdom has no pointer capture, so the dom test setup stubs it.

Worth flagging: with capture, dragging past the canvas edge now continues the gesture (marquee keeps growing, pan keeps panning) instead of ending it at the edge. That is the desktop-standard behavior, but it is a feel change you will notice immediately.

I have not been able to reproduce the original field desync on demand, so this fixes the mechanisms found by reading the stack rather than a confirmed repro. Worth a hands-on session before merging.

Testing

  • Rewrote the pan-liveness suite in pointer terms and extended it: 14 tests across capture (requested on down, survives capture throwing), liveness (eaten releases, heal-then-click), pairing (unpaired release never reaches the tool, cancel closes exactly once), and gesture exclusivity (no pan under a live drag, no tool down mid-pan).
  • Full suite 957 passing, typecheck clean, prettier clean.

Checklist

  • Changelog updated
  • Tests pass

The pan/drag desyncs kept coming back because the input stack was mouse
events plus compensating patches, and element-level mouse events
structurally cannot see a release that happens off-canvas. Worse, two of
the patches caused their own desyncs: mouseleave committed drags at the
canvas edge, and an unpaired mouseup fired a phantom click into the
tool. Mouse chords could also start a pan under an active drag.

Rework the entry layer to pointer events with setPointerCapture: a
gesture that starts on the canvas owns the pointer until pointerup or
pointercancel, wherever the cursor goes. Three invariants, each pinned
by tests:

- liveness: e.buttons is ground truth; stale gestures close out before
  anything acts on them (kept as backstop for when capture fails)
- pairing: tools only see onMouseUp for a drag the canvas started
- one gesture at a time: downs are ignored while a gesture is live

Tools keep their onMouseDown/Move/Up interface; only the canvas entry
layer changed. jsdom lacks pointer capture, so setupDom stubs it.
@rebaserHEAD
rebaserHEAD merged commit 4ac55b3 into main Jul 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant